Skip to content

BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717

Draft
johanrd wants to merge 10 commits intoember-cli:masterfrom
johanrd:fix/heading-accept-boolean-aria-hidden
Draft

BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717
johanrd wants to merge 10 commits intoember-cli:masterfrom
johanrd:fix/heading-accept-boolean-aria-hidden

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 21, 2026

This PR is part of a Phase 3 a11y-parity audit against jsx-a11y / vue-a11y / angular-eslint-template / lit-a11y.

  • Premise: A heading that's marked as decorative via aria-hidden is intentionally invisible to assistive technology — requiring text content in that heading is a false positive.
  • Problem: Our isHidden check only matched aria-hidden="true" as a case-sensitive string literal, so <h1 aria-hidden>, <h1 aria-hidden="">, <h1 aria-hidden="TRUE">, <h1 aria-hidden={{true}}>, etc. were treated as visible and flagged for missing content.

Fix: extract isAriaHiddenTruthy() that recognizes every plausible "hide-this" form (valueless, empty-string, "true" case-insensitive, mustache boolean/string-literal true/case-variants).

Prior art

Plugin Rule Verified behavior
jsx-a11y heading-has-content Flags empty <h1><h6>; skips hidden headings via isHiddenFromAT (jsx-a11y's aria-hidden interpretation — see Contested-semantics table below).
vuejs-accessibility heading-has-content Flags empty <h1><h6>; uses isHiddenFromScreenReader (vue's (value || "").toString() !== "false" — see Contested-semantics table below).
@angular-eslint/template elements-content Covers <h1><h6> together with <a> / <button> via a single regex matcher; skips hidden via isHiddenFromScreenReader. Not a dedicated heading rule.
lit-a11y no direct equivalent lit-a11y ships heading-hidden (headings must not be hidden) but no "heading has content" rule. Inverse concern.

Four ecosystem positions on valueless aria-hidden

The question "what does <el aria-hidden> (bare), aria-hidden="" (empty), or aria-hidden={{false}} mean?" has no single authoritative answer. Four defensible positions exist:

# Source Interpretation Evidence
1 jsx-a11y Valueless → hidden Side effect of jsx-ast-utils coercing valueless JSX attrs to boolean true, combined with rule check ariaHidden === true. Quirk: string aria-hidden="true" is NOT recognized because "true" !== true. Not a deliberate ARIA interpretation.
2 vue-a11y Anything not literal "false" → hidden isHiddenFromScreenReader.ts: (value || "").toString() !== "false". Catches valueless, empty, "TRUE", "anything". Non-spec shortcut.
3 axe-core / W3C ACT Rules Valueless/empty → INCOMPLETE, needs author review axe-core PR #3635: empty ARIA values reported as incomplete, "There is no real difference between an empty ARIA attribute, a null attribute, and not having the attribute at all." W3C ACT Rule 6a7281 explicitly scopes out empty values as inapplicable.
4 WAI-ARIA 1.2 spec Valueless/empty → default undefined → not hidden §aria-hidden value table: value type is true/false/undefined (default). Missing/empty resolves to the default. aria-hidden is NOT an HTML boolean attribute — the HTML spec never designates it as such.

Browser testing shows disagreement even on the explicit aria-hidden="true" case (see Steve Faulkner's post and Mozilla bug 948540); no documented browser testing on valueless specifically — most likely a no-op matching the spec's undefined-default.

Design choice for this rule

We lean toward fewer false positives. A linter that flags a heading the author intentionally marked decorative (via bare aria-hidden) creates friction and loss of trust; a linter that silently accepts some genuinely-empty unhidden headings is the smaller cost when the signal is ambiguous. So any aria-hidden form that could plausibly mean "hide this" exempts the heading from the empty-content check.

Exempt (don't flag empty heading):

  • <h1 aria-hidden>, <h1 aria-hidden="">, <h1 aria-hidden="true">, "TRUE", "True"
  • <h1 aria-hidden={{true}}>, {{"true"}}, {{"TRUE"}}, {{"True"}}

Still flag (explicit opt-in to the content check):

  • <h1 aria-hidden="false">, {{false}}, {{"false"}}

Tests

Valid (heading exempted):

  • Valueless, empty, "true" / "TRUE" / "True", {{true}}, {{"true"}} / case-variants

Invalid (falsy explicitly → flagged):

  • <h1 aria-hidden="false"></h1>, {{false}}, {{"false"}}

Before: isHidden only matched aria-hidden="true" as a string literal.
Boolean / valueless / empty / mustache forms (<h1 aria-hidden />,
<h1 aria-hidden="" />, <h1 aria-hidden={{true}} />) slipped past as
"not hidden", so empty headings in those forms were flagged as empty
even when the author had intentionally hidden them from AT.

Fix: extract isAriaHiddenTruthy(). Recognize:
- valueless attribute (HBS AST has value=null or empty-string TextNode)
- "true" string literal (preserved)
- "" empty string
- {{true}} boolean mustache literal
- {{"true"}} string mustache literal

Per HTML boolean-attribute semantics (and jsx-a11y/vue-a11y convention),
presence of aria-hidden without an explicit "false" value is treated as
truthy. The strict ARIA spec treats bare aria-hidden as "undefined"
rather than "true", but every major linter in the ecosystem (and most
screen readers) treats it as true.

Four new test cases covering each of the recognized forms.
johanrd added 3 commits April 21, 2026 18:04
…itively

HTML attribute value comparison is ASCII case-insensitive per spec, so
`aria-hidden="TRUE"` and `aria-hidden="True"` (and their mustache-string
equivalents) should be recognised as truthy. Mirrors the same case-
handling choice made in ember-cli#2718 for `kind="captions"`.

Tests cover `"TRUE"`, `"True"`, `{{"TRUE"}}`, `{{"True"}}`.
Adds invalid tests for `aria-hidden={{false}}` and `aria-hidden={{"false"}}`
to lock down that falsy mustache values do not exempt an otherwise-empty
heading.
…ARIA spec

Per WAI-ARIA 1.2 §6.6, `aria-hidden` has value type true/false/undefined
with default `undefined`. Per §8.5, missing or empty-string attribute
values resolve to the default. So a valueless `aria-hidden` is NOT
hidden per spec — only an explicit `"true"` (ASCII case-insensitive per
HTML enumerated-attribute rules) hides the element.

The earlier direction of this PR borrowed the HTML boolean-attribute
intuition (presence = truthy) from jsx-a11y. That's a peer-plugin
convention, not a spec mandate — aria-hidden is an enumerated ARIA
attribute, not a boolean HTML one. vue-a11y's heading-has-content
doesn't exempt aria-hidden headings at all; lit-a11y has the inverse
rule.

Behaviour now:
- Exempt (hidden): `aria-hidden="true"` / "TRUE" / "True", `{{true}}`,
  `{{"true"}}` / case-variants.
- Flag (NOT hidden per spec): valueless `<h1 aria-hidden>`, empty
  `<h1 aria-hidden="">`, `{{false}}`, `{{"false"}}`, `"false"`.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…idden default

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to default `undefined` — NOT `true`. Valueless
`<button aria-hidden>` and empty `<button aria-hidden="">` are therefore
NOT spec-hidden; they do not create a focus-trap anti-pattern and the
rule should not flag them.

The prior behavior inherited jsx-a11y's convention (jsx-ast-utils
coerces valueless JSX attrs to boolean true) and vue-a11y's
"anything-not-literal-false" shortcut. Both are peer-plugin conventions,
not normative ARIA interpretations. Matching ember-cli#2717's spec-first
resolution.

Also corrects the rule-doc comment: the claim attributed to WAI-ARIA 1.2
("Authors SHOULD NOT use aria-hidden='true' on any element that has
focus or may receive focus") is not in the WAI-ARIA spec. The spec only
says authors MAY "with caution" use aria-hidden. The rule's concern
(keyboard trap) comes from community/axe guidance, which this comment
now accurately attributes.

Net: flagged values are now `aria-hidden="true"` (ASCII case-insensitive),
`aria-hidden={{true}}`, and `aria-hidden={{"true"}}`. Valueless, empty,
`false`, and `{{false}}` are all accepted.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…ML boolean semantics

Per HTML Living Standard on boolean attributes, the presence of `autofocus`
indicates TRUE regardless of value — `autofocus="false"` and
`autofocus="autofocus"` are equally truthy. jsx-a11y's `no-autofocus`
treats the literal string `"false"` as an opt-out (via `getPropValue`),
but that's a peer-plugin convention that diverges from HTML semantics;
vue-a11y and lit-a11y are presence-based, consistent with the spec.

Narrow opt-out to the only case that is spec-consistent:
- `autofocus={{false}}` in angle-bracket syntax — renders no attribute.
- `{{input autofocus=false}}` in mustache hash-pair syntax — no attribute.

Revert peer-parity opt-outs for `autofocus="false"`, `autofocus={{"false"}}`,
and `{{input autofocus="false"}}` — these are now flagged per HTML spec
semantics. Moved from valid → invalid in the test suite.

Dialog exemption unchanged — keeps MDN-backed behavior for autofocus on
and within <dialog>.

Follows the spec-first direction established in ember-cli#2717 (aria-hidden),
#19, #33.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…I-ARIA spec

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to the default `undefined` — NOT `true`. So
<span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the
span; its content still contributes to the anchor's accessible name.

The prior behavior inherited jsx-a11y's JSX-coercion convention and
vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin
conventions that diverge from normative ARIA. Matches the spec-first
resolution of ember-cli#2717, #19, and #33.

Moved valueless / empty aria-hidden cases from invalid → valid. Kept the
explicit aria-hidden="true" and {{true}} cases as invalid.
johanrd added 2 commits April 21, 2026 20:23
…less aria-hidden

The valueless / empty-string aria-hidden case is genuinely contested in
the ecosystem — four positions exist (jsx-a11y / vue-a11y / axe-core /
WAI-ARIA spec), and no single authoritative source is decisive. Rather
than pick one interpretation and live with its false positives, this
rule leans toward fewer-false-positives: any aria-hidden form that could
plausibly mean "hide this" exempts the heading from the empty-content
check.

Truthy (exempt heading):
- valueless `<h1 aria-hidden>` — undefined-default per spec, but
  authors who write bare aria-hidden plausibly intend to hide.
- empty `<h1 aria-hidden="">` — same.
- `aria-hidden="true"` (ASCII case-insensitive) — unambiguous.
- `aria-hidden={{true}}` / `{{"true"}}` (case-insensitive) — unambiguous.

Falsy (still flag empty heading):
- `aria-hidden="false"`, `{{false}}`, `{{"false"}}` — explicit opt-out.

This reverses the previous spec-first direction on the valueless/empty
case. Rationale: a linter that flags intentional decorative markup
creates friction and loss of trust; a linter that misses some genuinely-
empty headings is preferable when the signal is ambiguous. The explicit
`aria-hidden="true"` cases, which ARE clearly hidden per spec, remain
exempt.
Move the explanation of valueless / empty-string aria-hidden handling
from the PR body into the published rule docs. The rule deviates from
WAI-ARIA 1.2 §aria-hidden (which resolves valueless aria-hidden to the
default 'undefined', not 'true') in order to favor fewer false
positives for this specific check.

Also document the 'opposite-direction' split with
template-no-aria-hidden-on-focusable / template-anchor-has-content
(where spec-literal interpretation applies), and the unambiguous cases
that always follow the spec.
`isAriaHiddenTruthy` previously only handled raw TextNode and bare
MustacheStatement attribute values. The quoted-mustache form
`aria-hidden="{{true}}"` produces a `GlimmerConcatStatement` with a
single mustache part — resolve that case by descending into the single
static-literal part, mirroring the pattern established in
template-no-aria-hidden-focusable.

Leans toward "truthy" only on literal true / empty / bare-valueless to
match the rule's doc-stated ethos of fewer false positives.
@johanrd johanrd force-pushed the fix/heading-accept-boolean-aria-hidden branch from 0bcced1 to a02b3e9 Compare April 22, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant